Skip to content

feat(web): optional activity-ordered sidebar - #10

Open
Antisophy wants to merge 2 commits into
CyberShadow:masterfrom
Antisophy:feat/sidebar-recency-order
Open

Antisophy wants to merge 2 commits into
CyberShadow:masterfrom
Antisophy:feat/sidebar-recency-order

Conversation

@Antisophy

@Antisophy Antisophy commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Adds ui.sidebar.sort, with values creation (the default, the tid order the sidebar has always had) and activity. With activity, the sidebar is ordered by activity: the most recently active task sits at the top and the order updates as tasks are used.

ui:
  sidebar:
    sort: activity
  • A parent rises with its most recently active descendant, at any depth, so a task never sits stale while work happens beneath it.
  • Siblings re-sort among themselves and nothing is reparented, so the tree structure is unchanged.
  • Archive and Import move below the live tasks; New Task moves to the top.
  • The list opens at its top in this mode (second commit), since .sidebar-list is column-reverse and would otherwise rest at the least recently active end.

The activity signal

The ordering uses last_active, the timestamp the welcome page and search already sort by. last_active is cleared on session start, for crash recovery, so a task that was alive when the backend stopped has it recovered at startup. That recovery used the transcript's mtime, which is not activity: records written around a session's start and exit (Claude's last-prompt and cost-state, for example) touch the file without any work happening, so a restart moved alive tasks to the restart time, on the welcome page as well.

Recovery now takes the timestamp of the transcript's newest conversation record instead. cydo.workflow.history.last_turn reads only the tail of the file, since transcripts reach tens of megabytes; the window grows if the tail holds no conversation record. Claude and Codex record shapes are recognized, and a transcript with neither falls back to the mtime as before. The read covers the same tasks the mtime recovery did, those whose last_active was cleared, and the result is persisted as before. There is no new column or migration.

Default

With sort unset or creation, the creation-ordered list, the Archive and Import placement and the New Task position are exactly as before; the sort, the group placement, the New Task move and the scroll-to-top are each gated on activity. The last_active recovery change applies regardless, since it fixes the existing signal.

Tests

Sidebar.test.ts covers both modes: unchanged order with creation, activity order with activity, a parent raised by a deep descendant, siblings re-sorting without reparenting, and the group placement in each mode. last_turn.d unit tests cover a transcript whose newest records are not conversation, a transcript with no conversation records, malformed lines, a missing file, the Codex shape, and a conversation record buried past the first scan window.

@CyberShadow

Copy link
Copy Markdown
Owner

Ordering needs to know when a task was last worked on, and neither existing signal answers that.

The premise is flawed.

We already have a most-recently-active task ordering in the UI: on the front page. Instead of inventing a whole new mechanism that's used for the same thing in one part of the UI but not the other, we should instead fix that mechanism.

That merely resuming idle sessions bumps their transcript's mtime (and thus our activity timestamp) is an oversight. The cure is to improve that mechanism - check the logged timestamp of the last message signaling real activity instead of the directory entry's, memoized if necessary to avoid a read storm on startup.

@CyberShadow

CyberShadow commented Aug 29, 2026 •

Copy link
Copy Markdown
Owner

Adds a sidebar_sort_by_recency option, off by default.

Probably should make this a bit more future-proof with more structure, e.g.:

- ui:
  - sidebar:
    - sort: activity  # default being `creation` - which sorting by tid gives you naturally

@Antisophy
Antisophy force-pushed the feat/sidebar-recency-order branch from 71e0a48 to 716a416 Compare September 14, 2026 01:44
@Antisophy

Copy link
Copy Markdown
Contributor Author

Rebased onto current master, and added a second commit: in recency mode the list now opens at its top, on load, on every reconnect, and when the sidebar is shown. The two points above are still open; the last_turn_at this branch derives is already the last real message's logged timestamp read from the transcript tail, so the remaining work is making the front page use that same signal and moving the option under ui.sidebar.sort (default creation). That rework follows in this PR.

Adds ui.sidebar.sort, with values creation (the default, the tid order
the list has always had) and activity. With activity, the most recently
active task sits at the top and the order updates as tasks are used. A
parent rises with its most recently active descendant at any depth, so
a task does not sit stale above or below work happening beneath it.
Siblings re-sort among themselves and nothing is reparented, leaving
the tree structure untouched. Archive and Import move below the live
tasks, and New Task moves to the top.

The order uses last_active, the timestamp the welcome page and search
already sort by. last_active is cleared on session start, for crash
recovery, so a task that was alive when the backend stopped has it
recovered at startup, until now from the transcript's mtime. The mtime
is not activity: records written around a session's start and exit
(claude's last-prompt and cost-state, for example) touch the file
without any work happening, so a restart moved alive tasks to the
restart time, on the welcome page as well as in this ordering.

Recovery now takes the timestamp of the transcript's newest
conversation record. Only the tail of the file is read, since
transcripts reach tens of megabytes; the window grows if the tail holds
no such record. Claude and Codex record shapes are recognized, and for
a transcript with none of them recovery falls back to the mtime as
before. The read covers the same tasks the mtime recovery did, those
whose last_active was cleared, and the result is persisted as before.
.sidebar-list is column-reverse, so its resting scroll position is the
visual bottom; in activity mode that is the least recently active end,
the opposite of where the interesting tasks are. Scroll the top-most
child into view when the sidebar opens: on mount, when the task list
finishes loading (the first load, and every reconnect, which empties and
refills the list without the sidebar ever hiding), when the sidebar
becomes visible on mobile, and when activity ordering is switched on.
Keyed on the load completing rather than on the list having content,
since the list arrives in packets. It runs before the active-item
effect, so deep-linking an off-screen task still scrolls to it.
@Antisophy
Antisophy force-pushed the feat/sidebar-recency-order branch from 716a416 to 5e32e4c Compare October 2, 2026 02:43
@Antisophy

Copy link
Copy Markdown
Contributor Author

Reworked along both lines and force-pushed; the description is updated to match.

There is no new timestamp any more. The ordering uses last_active, the signal the welcome page and search already sort by, and the fix went into its recovery: a task whose last_active was cleared on session start used to get it back from the transcript's mtime at startup, and now gets the timestamp of the transcript's newest conversation record, read from the tail of the file. The mtime stays as the fallback when no recognized record is found (Claude and Codex shapes are recognized). That also fixes the restart skew on the welcome page. The migration is gone.

The option is now ui.sidebar.sort, creation by default or activity. The second commit, which opens the list at its top in activity mode, is unchanged apart from naming.

@CyberShadow

Copy link
Copy Markdown
Owner

Hold on, this does three things then:

  1. Change the sort key from tid / creation time to last-active time
  2. Change the sort direction to go newest first
  3. Change how last-active time is computed

This should then be delivered in three commits. The first two need their own options.

As for changing the sort direction, the newest-first assumption is quite embedded in the design. I assume this comes along with wanting tasks to be listed top-down, not bottom-up? So make the DOM order reversal optional and adjust the subtask subtree construction accordingly?

Comment thread source/cydo/server/app.d
if (exists(jp))
{
td.lastActive = timeLastModified(jp).stdTime;
td.lastActive = lastTurnStdTime(jp);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might need to memoise this (on file size probably), startup performance may otherwise suffer greatly. Can we get a measurement of before/after startup time with 50 active tasks and a cold cache?

Comment on lines +89 to +123
/// StdTime of one transcript line, or 0 if it is not a conversation turn.
///
/// Parsed by hand rather than by deserializing: this runs over every line of
/// every task's tail at startup, and the records carry large nested payloads
/// that would be built and thrown away.
///
/// Only conversation records count, in the two shapes recognized:
/// claude: {"type":"user"|"assistant", ..., "timestamp":"..."}
/// codex: {"timestamp":"...", "type":"response_item", "payload":{...}}
/// Every other record type is skipped.
private long turnTimestamp(const(char)[] line) nothrow
{
import std.string : indexOf;

if (line.length == 0)
return 0;
if (line.indexOf(`"type":"user"`) < 0
&& line.indexOf(`"type":"assistant"`) < 0
&& line.indexOf(`"type":"response_item"`) < 0)
return 0;

auto key = line.indexOf(`"timestamp":"`);
if (key < 0)
return 0;
auto valueStart = key + `"timestamp":"`.length;
auto rest = line[valueStart .. $];
auto close = rest.indexOf('"');
if (close < 0)
return 0;

try
return SysTime.fromISOExtString(rest[0 .. close]).stdTime;
catch (Exception)
return 0;
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't invent yet another place where we parse rollout files. This should be owned by the agent class.

Comment on lines +40 to +87
private long scanTail(string path, size_t window, out bool wholeFile) nothrow
{
import std.stdio : File;

wholeFile = false;
try
{
auto f = File(path, "rb");
scope(exit) f.close();
auto size = f.size();
if (size == 0)
{
wholeFile = true;
return 0;
}

ulong start = size > window ? size - window : 0;
wholeFile = start == 0;
f.seek(start);
auto buf = new ubyte[cast(size_t)(size - start)];
auto chunk = f.rawRead(buf);

auto text = cast(string) chunk.idup;
// a mid-file window almost certainly starts inside a record; that
// partial first line would fail to parse anyway, but dropping it keeps
// the intent explicit
if (!wholeFile)
{
import std.string : indexOf;
auto nl = text.indexOf('\n');
text = nl < 0 ? "" : text[nl + 1 .. $];
}

long newest = 0;
import std.algorithm : splitter;
foreach (line; text.splitter('\n'))
{
auto ts = turnTimestamp(line);
if (ts > newest)
newest = ts;
}
return newest;
}
catch (Exception)
return 0;
catch (Error)
return 0;
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And this should be a shared facility (but it sounds like you want to iterate over .jsonl lines in reverse order).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants